资料
为何选择Protocol Buffer
下面一个例子是一个简单的通讯录,可以读写人物信息到文件。每个人都有姓名,ID,email和电话号码。
你会如何序列化和检索这样的结构化数据?下面有几种方案:
- 使用Java序列化。因为它内置于语言中,所以是最常见的实现,但它有许多众所周知的问题。并且和使用C ++或Python编写的应用程序共享数据时效果并不好。
- 你可以发明一种特殊的方法将数据项编码为单个字符串,比如4个int型的编码:”12:3:-23:67”。这是一个简单灵活的方案,虽然它确实需要编写一次性编码和解析代码,并且解析会产生很小的运行时间成本。这个很适合于编码很简单的数据。
- 序列化数据到XML。由于XML的可读性,这个方案会更加受欢迎,而且有许多语言的绑定库。因此如果要与其他项目分享数据时就是一个好的选择。然而,XML是众所周知的空间密集型,并且编/解码会对应用程序造成巨大的性能损失。此外,遍历XML DOM树比通常在类中遍历简单字段要复杂得多。
对于解决这个问题,Protocol Buffer是灵活,高效,自动化的解决方案。使用协议缓冲区,你可以编写.proto描述存储的数据结构。由此,Protocol Buffer编译器创建一个类,该类使用有效的二进制格式实现Protocol Buffer数据的自动编码和解析。生成的类为构成Protocol Buffer的字段提供getter和setter,并负责将Protocol Buffer作为一个单元进行读写的详细信息。重要的是,Protocol Buffer格式支持随着时间的推移扩展格式的想法,使得代码仍然可以读取用旧格式编码的数据。
定义你的Protocol格式
要创建你的地址簿,你需要首先创建一个.proto
文件。这个文件里的定义很简单:为每一个你需要序列化的数据结构添加一个消息,然后为消息中的每一个域指定一个name和type。
addressbook.proto
:
syntax = "proto2";
package tutorial;
option java_package = "com.example.tutorial";
option java_outer_classname = "AddressBookProtos";
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
required string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
文件语法类似于C++或Java,下面具体了解一下文件结构。
文件由包声明开始,这有助于防止不同项目之间的命名冲突。在Java语言中,包名用作Java包,除非明确指定了一个java_package
(如代码所做)。即使你定义了java_package
,你也仍然要定义一个普通的package
来避免在Protocol Buffer命名空间的名称冲突,尤其是在非Java语言中。
包声明之后是两行Java的可选项:java_package
和java_outer_classname
。
java_package
,指定生成的类应该以什么Java包名称存在。如果没有明确指定,它简单地匹配由package
声明的包名,但这些名称通常不是合适的Java包名称(因为它们通常不以域名开头)。java_outer_classname
,定义应包含此文件中所有类的类名。如果没有显示指定java_outer_classname
,它会把文件名转换为驼峰命名来生成。比如,”my_proto.proto”会转化成”MyProto”作为外部类名。
下一行就是message
的定义。消息只是包含一组类型字段的聚合。许多标准的简单数据类型都可用作字段类型,包括bool,int32,float,double和string。你也可以使用其他message
类型作为域类型为当前message
增加深层次的结构。上面例子中,Person message就包含了PhoneNumber message,同时,AddressBook message也包含了Person message。你甚至可以在其他message内定义内联的message类型,PhoneNumber就是这样的。如果你希望其中一个字段具有预定义的值列表之一,你可以使用enum实现。代码中PhoneNumber就是MOBILE
,HOME
或者WORK
三种。
每个元素的”=1”,”=2”标记标识该字段在二进制编码中使用的唯一“标记”。标记1-15比高的数字需要的编码少一个字节,因此,作为优化,您可以决定将这些标记用于常用或重复元素,将标记16和更高的数字留给不太常用的可选元素。重复字段中的每个元素都需要重新编码标记号,因此重复字段特别适合此优化。
必须使用以下修饰符之一注释每个字段:
required
:必须提供该字段的值,否则该消息将被视为未初始化(uninitialized)。如果编译一个未初始化的消息会抛出RuntimeException
的异常。解析未初始化的消息会抛出IOException
的异常。除此之外,required
的字段和optional
表现一致。optional
:该字段可以不赋予初始值。如果一个optional
字段值没有设置,会赋予一个默认值。对于简单的类型,你可以指定你自己的默认值,就像前面对PhoneNumber内type做的一样:optional PhoneType type = 2 [default = HOME];
否则,系统会给与一个默认值:数字是0,字符串是空字串,bool值是false。
对于嵌入的message,默认值总是该message的默认实例或者
prototype
,该消息没有设置任何字段。调用accessor以获取尚未显式设置的optional
(或required
)字段的值始终返回该字段的默认值。repeated
:该字段可以重复任意次数(包括零)。重复值的顺序将保留在协议缓冲区中。将重复字段视为动态大小的数组。
编译Protocol Buffers
现在有了一个.proto
文件,你需要做的下一件事是生成读取和写入AddressBook(以及Person和PhoneNumber)消息所需的类。要完成它,你需要运行Protocol Buffer编译器protoc
编译.proto
文件:
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
比如我的环境下,
{18-07-20 15:08}wOw-RMBP:~/Projects/Protobuf wow% ls
addressbook.proto
{18-07-20 15:08}wOw-RMBP:~/Projects/Protobuf wow% protoc -I=./ --java_out=./ ./addressbook.proto
{18-07-20 15:09}wOw-RMBP:~/Projects/Protobuf wow% ls
addressbook.proto com
生成的文件结构为
|____addressbook.proto
|____com
| |____example
| | |____tutorial
| | | |____AddressBookProtos.java
生成的java文件有2816行,就不贴出来了。下面会对java文件做一些解释。
Protocol Buffer API
从AddressBookProtos.java
看到定义了一个AddressBookProtos
类,其中嵌套了你在addressbook.proto
中指定的每条消息的类。每个类都有自己的Builder
类,你可以通过其创建实例。
public final class AddressBookProtos {
public interface PersonOrBuilder extends
// @@protoc_insertion_point(interface_extends:tutorial.Person)
com.google.protobuf.MessageOrBuilder {
}
public static final class Person extends
com.google.protobuf.GeneratedMessageV3 implements
// @@protoc_insertion_point(message_implements:tutorial.Person)
PersonOrBuilder {
}
public interface PhoneNumberOrBuilder extends
// @@protoc_insertion_point(interface_extends:tutorial.Person.PhoneNumber)
com.google.protobuf.MessageOrBuilder {
}
public static final class PhoneNumber extends
com.google.protobuf.GeneratedMessageV3 implements
// @@protoc_insertion_point(message_implements:tutorial.Person.PhoneNumber)
PhoneNumberOrBuilder {
}
public interface AddressBookOrBuilder extends
// @@protoc_insertion_point(interface_extends:tutorial.AddressBook)
com.google.protobuf.MessageOrBuilder {
}
public static final class AddressBook extends
com.google.protobuf.GeneratedMessageV3 implements
// @@protoc_insertion_point(message_implements:tutorial.AddressBook)
AddressBookOrBuilder {
}
}
messages和builders都为消息的每个字段都有自动生成的访问器方法;消息只有getter,而构建器有getter和setter。以下是Person类的一些访问器(为简洁起见省略了实现):
// required string name = 1;
public boolean hasName();
public String getName();
// required int32 id = 2;
public boolean hasId();
public int getId();
// optional string email = 3;
public boolean hasEmail();
public String getEmail();
// repeated .tutorial.Person.PhoneNumber phones = 4;
public List getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(int index);
同时,Person.Builder也有相同的getter和setter
// required string name = 1;
public boolean hasName();
public java.lang.String getName();
public Builder setName(String value);
public Builder clearName();
// required int32 id = 2;
public boolean hasId();
public int getId();
public Builder setId(int value);
public Builder clearId();
// optional string email = 3;
public boolean hasEmail();
public String getEmail();
public Builder setEmail(String value);
public Builder clearEmail();
// repeated .tutorial.Person.PhoneNumber phones = 4;
public List getPhonesList();
public int getPhonesCount();
public PhoneNumber getPhones(int index);
public Builder setPhones(int index, PhoneNumber value);
public Builder addPhones(PhoneNumber value);
public Builder addAllPhones(Iterable value);
public Builder clearPhones();
每个字段都有简单的JavaBeans样式的getter和setter。每个单独的字段都有getter,如果已设置该字段,则返回true。最后,每个字段都有一个清除方法,将字段取消设置回其空状态。
Repeated字段有一些额外的方法:一个Count
方法(它只是列表大小的简写),getter和setter通过索引get或set列表的特定元素,add
方法将新元素附加到列表,以及一个addAll
方法,它将整个容器中的元素添加到列表中。
注意这些accessor方法如何使用驼峰式命名,即使.proto
文件使用带下划线的小写。此转换由Protocol Buffer编译器自动完成,以便生成的类与标准Java样式约定匹配。您应该始终在.proto
文件中使用带有下划线的小写字母作为字段名称,这确保了所有生成语言的良好命名实践。有关良好的.proto样式的更多信息,请参阅样式指南。
Enums和内嵌类
生成的代码包含PhoneType
集合,内嵌于Person
类中。
public enum PhoneType
implements com.google.protobuf.ProtocolMessageEnum {
/**
* MOBILE = 0;
*/
MOBILE(0),
/**
* HOME = 1;
*/
HOME(1),
/**
* WORK = 2;
*/
WORK(2),
;
/**
* MOBILE = 0;
*/
public static final int MOBILE_VALUE = 0;
/**
* HOME = 1;
*/
public static final int HOME_VALUE = 1;
/**
* WORK = 2;
*/
public static final int WORK_VALUE = 2;
...
}
内嵌类Person.PhoneNumber
也一并生成,代码结构在前面API可以看到。
Builders vs. Messages
Protocol Buffer编译器生成的消息类都是不可变的。一旦构造了消息对象,就不能像Java String一样修改它。要构造消息,必须首先构造构建器,将要设置的任何字段设置为所选值,然后调用构建器的build
方法。
你可能已经注意到每个修改消息的构建器的方法都返回另一个构建器。返回的对象实际上是您调用该方法的同一个构建器。为方便起见,它会返回,以便您可以在一行代码中将多个setter串在一起。(链式编程)
下面是个创建Person
对象的例子:
Person john =
Person.newBuilder()
.setId(1234)
.setName("John Doe")
.setEmail("jdoe@example.com")
.addPhones(
Person.PhoneNumber.newBuilder()
.setNumber("555-4321")
.setType(Person.PhoneType.HOME))
.build();
标准Message方法
每个message和builder类还包含许多其他方法,可用于检查或操作整个消息,包括:
isInitialized()
:检查是否已设置所有必填字段。toString()
:返回一个人类可读的消息表示,对调试特别有用。mergeFrom(Message other)
:(仅限构建器)将其他内容合并到此消息中,覆盖奇异标量字段,合并复合字段以及连接重复字段。clear()
:(仅限构建器)将所有字段清除回空状态。
这些方法实现了所有Java消息和构建器共享的Message
和Message.Builder
接口。有关更多信息,请参阅Message的完整API文档。
解析和序列化
最后,每个Protocol Buffer类都有使用Protocol Buffer二进制格式编写和读取所选类型消息的方法。这些包括:
byte [] toByteArray();
:序列化消息并返回包含其原始字节的字节数组。static Person parseFrom(byte[] data);
:解析来自给定字节数组的消息。void writeTo(OutputStream output);
:序列化消息并将其写入OutputStream。static Person parseFrom(InputStream input);
:读取并解析来自InputStream的消息。
这些只是解析和序列化提供的几个选项。
编写一个Message
现在让我们尝试使用生成的Protocol Buffer类。你希望地址簿应用程序能够做的第一件事是将个人详细信息写入你的地址簿文件。为此,你需要创建并填充Protocol Buffer类的实例,然后将它们写入输出流。
这是一个从文件读取AddressBook的程序,根据用户输入向其添加一个新Person,并将新的AddressBook再次写回文件。直接调用或引用协议编译器生成的代码的部分将用*标识。
* import com.example.tutorial.AddressBookProtos.AddressBook;
* import com.example.tutorial.AddressBookProtos.Person;
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.PrintStream;
class AddPerson {
// This function fills in a Person message based on user input.
static Person PromptForAddress(BufferedReader stdin,
PrintStream stdout) throws IOException {
* Person.Builder person = Person.newBuilder();
stdout.print("Enter person ID: ");
* person.setId(Integer.valueOf(stdin.readLine()));
stdout.print("Enter name: ");
* person.setName(stdin.readLine());
stdout.print("Enter email address (blank for none): ");
String email = stdin.readLine();
if (email.length() > 0) {
* person.setEmail(email);
}
while (true) {
stdout.print("Enter a phone number (or leave blank to finish): ");
String number = stdin.readLine();
if (number.length() == 0) {
break;
}
* Person.PhoneNumber.Builder phoneNumber =
* Person.PhoneNumber.newBuilder().setNumber(number);
stdout.print("Is this a mobile, home, or work phone? ");
String type = stdin.readLine();
if (type.equals("mobile")) {
* phoneNumber.setType(Person.PhoneType.MOBILE);
} else if (type.equals("home")) {
* phoneNumber.setType(Person.PhoneType.HOME);
} else if (type.equals("work")) {
* phoneNumber.setType(Person.PhoneType.WORK);
} else {
stdout.println("Unknown phone type. Using default.");
}
* person.addPhones(phoneNumber);
}
* return person.build();
}
// Main function: Reads the entire address book from a file,
// adds one person based on user input, then writes it back out to the same
// file.
public static void main(String[] args) throws Exception {
if (args.length != 1) {
System.err.println("Usage: AddPerson ADDRESS_BOOK_FILE");
System.exit(-1);
}
* AddressBook.Builder addressBook = AddressBook.newBuilder();
// Read the existing address book.
try {
* addressBook.mergeFrom(new FileInputStream(args[0]));
} catch (FileNotFoundException e) {
System.out.println(args[0] + ": File not found. Creating a new file.");
}
// Add an address.
* addressBook.addPeople(
PromptForAddress(new BufferedReader(new InputStreamReader(System.in)),
System.out));
// Write the new address book back to disk.
FileOutputStream output = new FileOutputStream(args[0]);
* addressBook.build().writeTo(output);
output.close();
}
}
读取一个Message
当然,如果你无法从中获取任何信息,那么地址簿就不会有多大用处!此示例读取上面示例创建的文件并打印其中的所有信息。
* import com.example.tutorial.AddressBookProtos.AddressBook;
* import com.example.tutorial.AddressBookProtos.Person;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.PrintStream;
class ListPeople {
// Iterates though all people in the AddressBook and prints info about them.
static void Print(AddressBook addressBook) {
for (Person person: addressBook.getPeopleList()) {
System.out.println("Person ID: " + * person.getId());
System.out.println(" Name: " + * person.getName());
if (person.hasEmail()) {
System.out.println(" E-mail address: " + * person.getEmail());
}
for (* Person.PhoneNumber phoneNumber : person.getPhonesList()) {
switch (* phoneNumber.getType()) {
case * MOBILE:
System.out.print(" Mobile phone #: ");
break;
case * HOME:
System.out.print(" Home phone #: ");
break;
case * WORK:
System.out.print(" Work phone #: ");
break;
}
System.out.println(* phoneNumber.getNumber());
}
}
}
// Main function: Reads the entire address book from a file and prints all
// the information inside.
public static void main(String[] args) throws Exception {
if (args.length != 1) {
System.err.println("Usage: ListPeople ADDRESS_BOOK_FILE");
System.exit(-1);
}
// Read the existing address book.
* AddressBook addressBook =
* AddressBook.parseFrom(new FileInputStream(args[0]));
Print(addressBook);
}
}
拓展一个Protocol Buffer
在释放使用Protocol Buffer的代码之后,你无疑会想要“改进”Protocol Buffer的定义。如果你希望你的新缓冲区向后兼容,并且你的旧缓冲区是向前兼容的 - 而且你几乎肯定想要这个 - 那么你需要遵循一些规则。在新版本的协议缓冲区中:
- 不得更改任何现有字段的标记号。
- 不得添加或删除任何必填字段。
- 可以删除可选或重复的字段。
- 可以添加新的可选或重复字段,但必须使用新的标记号(即从未在此Protocol Buffer中使用的标记号,甚至不包括已删除的字段)。
如果你遵循这些规则,旧代码将可以阅读新消息并简单地忽略任何新字段。对于旧代码,已删除的可选字段将只具有其默认值,删除的重复字段将为空。新代码也将透明地读取旧消息。但是,请记住旧的消息中不会出现新的可选字段,因此您需要明确检查它们是否设置为has_
,或者使用[default = value]
在.proto
文件中提供合理的默认值。标签号后面。如果未为可选元素指定默认值,则使用特定于类型的默认值:对于string,默认值为空字符串。对于boolean,默认值为false。对于numeric,默认值为零。另请注意,如果添加了新的重复字段,则新代码将无法判断它是否为空(通过新代码)或从未设置(通过旧代码),因为它没有has_
标志。
进阶用法
Protocol Buffer 的用途不仅仅是简单的访问器和序列化。请务必浏览Java API参考,以了解你可以使用它们做些什么。
Protocol Buffer类提供的一个关键特性是反射。你可以迭代消息的字段并操纵它们的值,而无需针对任何特定的消息类型编写代码。使用反射的一种非常有用的方法是将Protocol Buffer 转换为其他编码,例如XML或JSON。更高级的反射用法可能是找到两个相同类型的消息之间的差异,或者开发一种“Protocol Buffer的正则表达式”,你可以在其中编写与某些消息内容匹配的表达式。如果你运用自己的想象力,可以将Protocol Buffer应用于比你最初预期更广泛的问题!
反射是作为Message
和Message.Builder
接口的一部分提供的。